Packages R sur R-Forge

Nous allons extraire la liste des paquets R de R-Forge, ainsi que les fichiers DESCRIPTION de ces paquets. La liste des paquets est disponible sur la page https://r-forge.r-project.org/softwaremap/full_list.php. Elle se présente sous la forme d'une liste paginée, qu'il va falloir parser pour récupérer le nom du package. Notez que nous récupèrerons le nom utilisé dans l'url du package et non le nom affiché, simplement parce que le premier, contrairement au deuxième, nous servira pour récupérer le fichier DESCRIPTION.

Chaque page (95 à l'heure où j'écris ces lignes) est accessible depuis https://r-forge.r-project.org/softwaremap/full_list.php?page=X où X est le numéro de page (1-indexed). La première chose à faire est de récupérer le nombre de pages.


In [20]:
import requests
import BeautifulSoup as bs

LIST_URL = 'https://r-forge.r-project.org/softwaremap/full_list.php?page={page}'

content = requests.get(LIST_URL.format(page=1)).content
soup = bs.BeautifulSoup(content)

anchors = soup.findAll('a')
N = max(
        map(lambda x: int(x), 
            map(lambda x: x['href'].rsplit('?page=', 1)[1], 
                filter(lambda x: x['href'].startswith('/softwaremap/full_list.php?page='), anchors)
            )
        )
    )

Ensuite, nous allons récupérer le contenu de chacune de ces pages et le parser afin de rechercher les noms des packages. Sur chaque page, <span property="doap:name"> précède le nom du package. Mais comme nous voulons récupérer le nom utilisé dans les liens, nous devons remonter au premier parent de type a afin d'extraire l'url, et de cette url, extraire le package.


In [21]:
def packages_list(url):
    content = requests.get(url).content
    soup = bs.BeautifulSoup(content)
    spans = soup.findAll('span', attrs={'property': 'doap:name'})
    anchors = [span.findParent('a', limit=1) for span in spans]
    return map(lambda a: a['href'].rsplit('/', 2)[1], anchors)

In [22]:
names = []

for n in range(1, N+1):
    names += packages_list(LIST_URL.format(page=n))

In [23]:
print len(names)


1883

Après un certain temps (souvenez-vous : il faut récupérer et parser N (~95) pages !), names contient la liste des noms de package utilisés dans les urls. Chaque package possède potentiellement un fichier DESCRIPTION sur son svn. Par exemple, pour le package rcppbind, l'adresse est https://r-forge.r-project.org/scm/viewvc.php/*checkout*/pkg/DESCRIPTION?root=rcppbind

Tous les packages n'ont pas forcément un tel fichier. Le "package" epicookbook affiche une erreur 404 quand on accède à la page https://r-forge.r-project.org/scm/viewvc.php/*checkout*/pkg/DESCRIPTION?root=epicookbook. Malheureusement pour nous, le code de retour n'est pas 404. Il va donc falloir "parser" (brièvement et simplement) le contenu en cas de requête, afin d'identifier si le retour est une "page 404" ou un réel fichier DESCRIPTION. On peut y parvenir simplement parce que la seconde ligne est composée de <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN". Il y a aussi des erreurs 403 (enfin, un équivalent) restreignant l'accès à certains packages. Ces contenus débutent par <?xml.

Il y a aussi une autre subtilité : certains dépôts contiennent plusieurs packages R. C'est le cas par exemple de SciViews. Dans un tel cas, chaque sous-répertoire de pkg contient potentiellement un package, contenant lui même potentiellement un fichier DESCRIPTION. Le parsing de ce listing se fait assez simplement : les noms qui nous intéressent sont un attribut name des balises a dont le titre est View directory contents.

La procédure pour récupérer ces informations peut donc se résumer à :

  • Tester l'existence du DESCRIPTION dans pkg.
  • Si le fichier est trouvé, le tour est joué.
  • Sinon, récupérer un listing du répertoire pkg.
  • Si le listing échoue, alors on passe au dépôt suivant.
  • Si non, on teste la récupération de DESCRIPTION pour chaque sous-répertoire de pkg.

In [24]:
DESCRIPTION_URL = 'https://r-forge.r-project.org/scm/viewvc.php/*checkout*/pkg/DESCRIPTION?root={name}'
PKG_DIR_URL = 'https://r-forge.r-project.org/scm/viewvc.php/pkg/?root={name}'
PKG_DESCRIPTION_URL = 'https://r-forge.r-project.org/scm/viewvc.php/*checkout*/pkg/{dir}/DESCRIPTION?root={name}'

def parse_DESCRIPTION(content):
    r = {}
    for line in content.split('\n'):
        if len(line.strip()) > 0:
            if line.startswith((' ', '\t')):
                r[key] += ' ' + line.strip() # key is already defined in this case
            else:
                try:
                    key, value = line.split(':', 1)
                    r[key.strip()] = value.strip()
                except Exception as e:
                    print line
                    print 'len is', len(line)
                    print '---'
                    print content
                    print '---'
                    # raise
    return r

In [25]:
errors = []
d = {}

In [26]:
for i, name in enumerate(names):
    if name in d:  # moins lent que de récupérer et parser la page
        continue
    
    content = requests.get(DESCRIPTION_URL.format(name=name)).content
    # Check for /pkg/DESCRIPTION
    if content.startswith(('\n<?xml', '\nInvalid repository type')):
        # Looks like an error
        errors.append(name)
    elif content.startswith('\n<!DOCTYPE'):
        # DESCRIPTION is missing, looks for /pkg/
        content = requests.get(PKG_DIR_URL.format(name=name)).content
        # Get subdirectories
        soup = bs.BeautifulSoup(content)
        for anchor in soup.findAll('a', attrs={'title': 'View directory contents'}):
            content = requests.get(PKG_DESCRIPTION_URL.format(name=name, dir=anchor['name'])).content
            if not content.startswith('\n<!DOCTYPE'):
                d[anchor['name']] = parse_DESCRIPTION(content)
    else:
        # DESCRIPTION file is found
        d[name] = parse_DESCRIPTION(content)


. . . . . . . . . . . . . . . . It's main intention is to keep rpnf lean and without almost any dependency.
len is 75
---

Package: rpnfext
Type: Package
Title: Extentions of the R Point and Figure Library rpnf
Version: 0.0.1
Date: 2014-05-06
Author: Sascha M. Herrmann
Maintainer: <Sascha.Martin.Herrmann@gmail.com>
Description: This package contains extentions, wrappers and evaluation function around the rpnf package.
It's main intention is to keep rpnf lean and without almost any dependency.
License: MIT

---
. . . . . . . . . . . . . . . . . . . modified traits.
len is 17
---

Package: rseedcalc
Type: Package
Title: Estimation of the proportion of genetically modified stacked seeds in seedlots
Version: 1.00
Date: 2010-07-04
Author: Kevin Wright, Jean-Louis Laffont
Maintainer: Kevin Wright <kw.stat@gmail.com>
Description: Estimate the percentage of seeds in a seedlot that contain stacks of genetically
modified traits.
Suggests: RExcelInstaller, rcom
License: GPL2
LazyLoad: yes

---
. . .

Après un certain temps (~1900 pages à parser, sans compter les listings et sous-répertoires), nous avons dans d le contenu suffisant pour générer un fichier .csv avec pandas !


In [27]:
# Before looking at subdirs: (773, 1109, 1882)
len(d), len(errors), len(names)


Out[27]:
(2217, 65, 1883)

In [28]:
import pandas

df = pandas.DataFrame.from_dict(d, orient='index')
df = df.drop_duplicates('Package')
df.to_csv('../data/r-forge_description.csv')